Skip to content

fix(api): normalize content field format before sending to provider - #67767

Open
jelloeater-agent wants to merge 1 commit into
NousResearch:mainfrom
jelloeater-agent:fix/content-format-sanitization
Open

jelloeater-agent wants to merge 1 commit into
NousResearch:mainfrom
jelloeater-agent:fix/content-format-sanitization

Conversation

@jelloeater-agent

Copy link
Copy Markdown

Problem

Bifrost and OpenRouter strictly validate the message content field, rejecting assistant messages whose content is neither a string nor an array of Content block dicts with HTTP 400: "content field is neither a string nor an array of Content blocks".

This is triggered when malformed content reaches the wire via:

  • Thinking-only prefill continuation — the model emits reasoning but no visible text, and the prefill message gets appended with content in an invalid format
  • Message merging — drop_thinking_only_and_merge_users can produce content as an array of plain strings instead of proper Content blocks

Fix

Add content format normalization to sanitize_api_messages(), the final pre-API chokepoint that already handles empty tool_calls, orphaned tool results, and duplicate call IDs.

Incoming content What gets sent to API
["hello", "world"] (strings array) [{"type":"text","text":"hello"}, {"type":"text","text":"world"}]
[{"text":"no type"}] (dicts missing type) [{"type":"text","text":"no type"}]
{"weird": "format"} (non-string, non-list) str(..)
None / valid string / valid content blocks Pass through unchanged

Testing

All core normalization paths verified:

  • Normal messages pass through unchanged
  • List of strings → proper Content block array
  • Dicts missing type → type="text" fallback added
  • Non-string/non-list values → string conversion
  • None content → passes through
  • Valid mixed content blocks preserved
  • Empty all-text blocks collapsed to empty string

Bifrost and OpenRouter strictly validate the message content field,
rejecting assistant messages whose content is neither a string nor
an array of Content block dicts with HTTP 400: 'content field is
neither a string nor an array of Content blocks'.

This was triggered when malformed content (e.g., an array of plain
strings like ["thinking", "text"], or dicts missing the 'type'
key) reached the wire via thinking-only prefill continuation or
message merging.

Fix: add content format normalization to sanitize_api_messages(),
the final pre-API chokepoint that already handles empty tool_calls,
orphaned tool results, and duplicate call IDs. Normalizations:
  - Array of plain strings -> Content block array
    [{'type':'text','text':'...'}, ...]
  - Dicts missing 'type' key -> add type='text' fallback
  - Non-string, non-list values -> convert to str
  - None/null -> pass through unchanged
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint provider/openrouter OpenRouter aggregator labels Jul 20, 2026
@alt-glitch

Copy link
Copy Markdown

This was generated by AI during triage.

Related to #64296 and #17632. This normalizes the content field at the final API-message sanitizer; those PRs preserve other strict-provider message fields, so the mechanisms are complementary.

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for targeting strict-provider message validation. The compatibility gap is still actionable: sanitize_api_messages() on current main has no content-shape normalization (agent/agent_runtime_helpers.py:2762-2957).

Problems

  • The new pass is not the final request mutation. The main loop calls _sanitize_api_messages() before _drop_thinking_only_and_merge_users() (agent/conversation_loop.py:1403-1416), so content can still be changed after this normalization.
  • The diff contains no regression tests. Existing merge coverage only asserts typed-block inputs and outputs (tests/run_agent/test_thinking_only_sanitizer.py:244-268).
  • The proposed merge explanation is not reproduced by current code: mixed string/list branches create typed text blocks, while list/list only concatenates its existing operands (agent/agent_runtime_helpers.py:1352-1369).

Suggested changes

  • Normalize after the drop/merge pass in both request paths, or prove later mutations cannot introduce invalid content.
  • Add full request-path tests for malformed lists, typeless dicts, scalar/dict values, and valid typed blocks.

Automated hermes-sweeper review.


# --- Normalize content field formats ────────────────────────────
# Bifrost/OpenRouter reject assistant messages whose `content` is
# neither a string nor an array of Content block dicts (e.g. an

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not the final content mutation in the main path: _drop_thinking_only_and_merge_users() runs after this sanitizer at agent/conversation_loop.py:1403-1416. Please place the normalization after that pass (and mirror the summary path), or demonstrate that the later pass cannot create an invalid wire shape.

@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-broad Sweeper blast radius: broad — a core path most sessions hit labels Jul 30, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P2 Medium — degraded but workaround exists provider/openrouter OpenRouter aggregator sweeper:blast-broad Sweeper blast radius: broad — a core path most sessions hit sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants